import url, {URL} from 'url'
import * as http from 'http'
import * as https from 'https'
import {Writable} from 'stream'

// Create handlers that pass events from native requests
var events = ['abort', 'aborted', 'connect', 'error', 'socket', 'timeout']
var eventHandlers = Object.create(null)
events.forEach(function (event) {
  eventHandlers[event] = function (arg1, arg2, arg3) {
    this._redirectable.emit(event, arg1, arg2, arg3)
  }
})

var InvalidUrlError = createErrorType('ERR_INVALID_URL', 'Invalid URL', TypeError)
// Error types with codes
var RedirectionError = createErrorType('ERR_FR_REDIRECTION_FAILURE', 'Redirected request failed')
var TooManyRedirectsError = createErrorType('ERR_FR_TOO_MANY_REDIRECTS', 'Maximum number of redirects exceeded')
var MaxBodyLengthExceededError = createErrorType(
  'ERR_FR_MAX_BODY_LENGTH_EXCEEDED',
  'Request body larger than maxBodyLength limit',
)
var WriteAfterEndError = createErrorType('ERR_STREAM_WRITE_AFTER_END', 'write after end')

// istanbul ignore next
var destroy = Writable.prototype.destroy || noop

// An HTTP(S) request that can be redirected
function RedirectableRequest(options, responseCallback) {
  // Initialize the request
  Writable.call(this)
  this._sanitizeOptions(options)
  this._options = options
  this._ended = false
  this._ending = false
  this._redirectCount = 0
  this._redirects = []
  this._requestBodyLength = 0
  this._requestBodyBuffers = []

  // Attach a callback if passed
  if (responseCallback) {
    this.on('response', responseCallback)
  }

  // React to responses of native requests
  var self = this
  this._onNativeResponse = function (response) {
    self._processResponse(response)
  }

  // Perform the first request
  this._performRequest()
}
RedirectableRequest.prototype = Object.create(Writable.prototype)

RedirectableRequest.prototype.abort = function () {
  destroyRequest(this._currentRequest)
  this._currentRequest.abort()
  this.emit('abort')
}

RedirectableRequest.prototype.destroy = function (error) {
  destroyRequest(this._currentRequest, error)
  destroy.call(this, error)
  return this
}

// Writes buffered data to the current native request
RedirectableRequest.prototype.write = function (data, encoding, callback) {
  // Writing is not allowed if end has been called
  if (this._ending) {
    throw new WriteAfterEndError()
  }

  // Validate input and shift parameters if necessary
  if (!isString(data) && !isBuffer(data)) {
    throw new TypeError('data should be a string, Buffer or Uint8Array')
  }
  if (isFunction(encoding)) {
    callback = encoding
    encoding = null
  }

  // Ignore empty buffers, since writing them doesn't invoke the callback
  // https://github.com/nodejs/node/issues/22066
  if (data.length === 0) {
    if (callback) {
      callback()
    }
    return
  }
  // Only write when we don't exceed the maximum body length
  if (this._requestBodyLength + data.length <= this._options.maxBodyLength) {
    this._requestBodyLength += data.length
    this._requestBodyBuffers.push({data: data, encoding: encoding})
    this._currentRequest.write(data, encoding, callback)
  }
  // Error when we exceed the maximum body length
  else {
    this.emit('error', new MaxBodyLengthExceededError())
    this.abort()
  }
}

// Ends the current native request
RedirectableRequest.prototype.end = function (data, encoding, callback) {
  // Shift parameters if necessary
  if (isFunction(data)) {
    callback = data
    data = encoding = null
  } else if (isFunction(encoding)) {
    callback = encoding
    encoding = null
  }

  // Write data if needed and end
  if (!data) {
    this._ended = this._ending = true
    this._currentRequest.end(null, null, callback)
  } else {
    var self = this
    var currentRequest = this._currentRequest
    this.write(data, encoding, function () {
      self._ended = true
      currentRequest.end(null, null, callback)
    })
    this._ending = true
  }
}

// Sets a header value on the current native request
RedirectableRequest.prototype.setHeader = function (name, value) {
  this._options.headers[name] = value
  this._currentRequest.setHeader(name, value)
}

// Clears a header value on the current native request
RedirectableRequest.prototype.removeHeader = function (name) {
  delete this._options.headers[name]
  this._currentRequest.removeHeader(name)
}

// Global timeout for all underlying requests
RedirectableRequest.prototype.setTimeout = function (msecs, callback) {
  var self = this

  // Destroys the socket on timeout
  function destroyOnTimeout(socket) {
    socket.setTimeout(msecs)
    socket.removeListener('timeout', socket.destroy)
    socket.addListener('timeout', socket.destroy)
  }

  // Sets up a timer to trigger a timeout event
  function startTimer(socket) {
    if (self._timeout) {
      clearTimeout(self._timeout)
    }
    self._timeout = setTimeout(function () {
      self.emit('timeout')
      clearTimer()
    }, msecs)
    destroyOnTimeout(socket)
  }

  // Stops a timeout from triggering
  function clearTimer() {
    // Clear the timeout
    if (self._timeout) {
      clearTimeout(self._timeout)
      self._timeout = null
    }

    // Clean up all attached listeners
    self.removeListener('abort', clearTimer)
    self.removeListener('error', clearTimer)
    self.removeListener('response', clearTimer)
    self.removeListener('close', clearTimer)
    if (callback) {
      self.removeListener('timeout', callback)
    }
    if (!self.socket) {
      self._currentRequest.removeListener('socket', startTimer)
    }
  }

  // Attach callback if passed
  if (callback) {
    this.on('timeout', callback)
  }

  // Start the timer if or when the socket is opened
  if (this.socket) {
    startTimer(this.socket)
  } else {
    this._currentRequest.once('socket', startTimer)
  }

  // Clean up on events
  this.on('socket', destroyOnTimeout)
  this.on('abort', clearTimer)
  this.on('error', clearTimer)
  this.on('response', clearTimer)
  this.on('close', clearTimer)

  return this
}

// Proxy all other public ClientRequest methods
;['flushHeaders', 'getHeader', 'setNoDelay', 'setSocketKeepAlive'].forEach(function (method) {
  RedirectableRequest.prototype[method] = function (a, b) {
    return this._currentRequest[method](a, b)
  }
})

// Proxy all public ClientRequest properties
;['aborted', 'connection', 'socket'].forEach(function (property) {
  Object.defineProperty(RedirectableRequest.prototype, property, {
    get: function () {
      return this._currentRequest[property]
    },
  })
})

RedirectableRequest.prototype._sanitizeOptions = function (options) {
  // Ensure headers are always present
  if (!options.headers) {
    options.headers = {}
  }

  // Since http.request treats host as an alias of hostname,
  // but the url module interprets host as hostname plus port,
  // eliminate the host property to avoid confusion.
  if (options.host) {
    // Use hostname if set, because it has precedence
    if (!options.hostname) {
      options.hostname = options.host
    }
    delete options.host
  }

  // Complete the URL object when necessary
  if (!options.pathname && options.path) {
    var searchPos = options.path.indexOf('?')
    if (searchPos < 0) {
      options.pathname = options.path
    } else {
      options.pathname = options.path.substring(0, searchPos)
      options.search = options.path.substring(searchPos)
    }
  }
}

// Executes the next native request (initial or redirect)
RedirectableRequest.prototype._performRequest = function () {
  // Load the native protocol
  var protocol = this._options.protocol
  var nativeProtocol = this._options.nativeProtocols[protocol]
  if (!nativeProtocol) {
    this.emit('error', new TypeError('Unsupported protocol ' + protocol))
    return
  }

  // If specified, use the agent corresponding to the protocol
  // (HTTP and HTTPS use different types of agents)
  if (this._options.agents) {
    var scheme = protocol.slice(0, -1)
    this._options.agent = this._options.agents[scheme]
  }

  // Create the native request and set up its event handlers
  var request = (this._currentRequest = nativeProtocol.request(this._options, this._onNativeResponse))
  request._redirectable = this
  for (var event of events) {
    request.on(event, eventHandlers[event])
  }

  // RFC7230§5.3.1: When making a request directly to an origin server, […]
  // a client MUST send only the absolute path […] as the request-target.
  this._currentUrl = /^\//.test(this._options.path)
    ? url.format(this._options)
    : // When making a request to a proxy, […]
      // a client MUST send the target URI in absolute-form […].
      this._options.path

  // End a redirected request
  // (The first request must be ended explicitly with RedirectableRequest#end)
  if (this._isRedirect) {
    // Write the request entity and end
    var i = 0
    var self = this
    var buffers = this._requestBodyBuffers
    ;(function writeNext(error) {
      // Only write if this request has not been redirected yet
      /* istanbul ignore else */
      if (request === self._currentRequest) {
        // Report any write errors
        /* istanbul ignore if */
        if (error) {
          self.emit('error', error)
        }
        // Write the next buffer if there are still left
        else if (i < buffers.length) {
          var buffer = buffers[i++]
          /* istanbul ignore else */
          if (!request.finished) {
            request.write(buffer.data, buffer.encoding, writeNext)
          }
        }
        // End the request if `end` has been called on us
        else if (self._ended) {
          request.end()
        }
      }
    })()
  }
}

// Processes a response from the current native request
RedirectableRequest.prototype._processResponse = function (response) {
  // Store the redirected response
  var statusCode = response.statusCode
  if (this._options.trackRedirects) {
    this._redirects.push({
      url: this._currentUrl,
      headers: response.headers,
      statusCode: statusCode,
    })
  }

  // RFC7231§6.4: The 3xx (Redirection) class of status code indicates
  // that further action needs to be taken by the user agent in order to
  // fulfill the request. If a Location header field is provided,
  // the user agent MAY automatically redirect its request to the URI
  // referenced by the Location field value,
  // even if the specific status code is not understood.

  // If the response is not a redirect; return it as-is
  var location = response.headers.location
  if (!location || this._options.followRedirects === false || statusCode < 300 || statusCode >= 400) {
    response.responseUrl = this._currentUrl
    response.redirects = this._redirects
    this.emit('response', response)

    // Clean up
    this._requestBodyBuffers = []
    return
  }

  // The response is a redirect, so abort the current request
  destroyRequest(this._currentRequest)
  // Discard the remainder of the response to avoid waiting for data
  response.destroy()

  // RFC7231§6.4: A client SHOULD detect and intervene
  // in cyclical redirections (i.e., "infinite" redirection loops).
  if (++this._redirectCount > this._options.maxRedirects) {
    this.emit('error', new TooManyRedirectsError())
    return
  }

  // Store the request headers if applicable
  var requestHeaders
  var beforeRedirect = this._options.beforeRedirect
  if (beforeRedirect) {
    requestHeaders = Object.assign(
      {
        // The Host header was set by nativeProtocol.request
        Host: response.req.getHeader('host'),
      },
      this._options.headers,
    )
  }

  // RFC7231§6.4: Automatic redirection needs to done with
  // care for methods not known to be safe, […]
  // RFC7231§6.4.2–3: For historical reasons, a user agent MAY change
  // the request method from POST to GET for the subsequent request.
  var method = this._options.method
  if (
    ((statusCode === 301 || statusCode === 302) && this._options.method === 'POST') ||
    // RFC7231§6.4.4: The 303 (See Other) status code indicates that
    // the server is redirecting the user agent to a different resource […]
    // A user agent can perform a retrieval request targeting that URI
    // (a GET or HEAD request if using HTTP) […]
    (statusCode === 303 && !/^(?:GET|HEAD)$/.test(this._options.method))
  ) {
    this._options.method = 'GET'
    // Drop a possible entity and headers related to it
    this._requestBodyBuffers = []
    removeMatchingHeaders(/^content-/i, this._options.headers)
  }

  // Drop the Host header, as the redirect might lead to a different host
  var currentHostHeader = removeMatchingHeaders(/^host$/i, this._options.headers)

  // If the redirect is relative, carry over the host of the last request
  var currentUrlParts = url.parse(this._currentUrl)
  var currentHost = currentHostHeader || currentUrlParts.host
  var currentUrl = /^\w+:/.test(location)
    ? this._currentUrl
    : url.format(Object.assign(currentUrlParts, {host: currentHost}))

  // Determine the URL of the redirection
  var redirectUrl
  try {
    redirectUrl = url.resolve(currentUrl, location)
  } catch (cause) {
    this.emit('error', new RedirectionError({cause: cause}))
    return
  }

  // Create the redirected request
  this._isRedirect = true
  var redirectUrlParts = url.parse(redirectUrl)
  Object.assign(this._options, redirectUrlParts)

  // Drop confidential headers when redirecting to a less secure protocol
  // or to a different domain that is not a superdomain
  if (
    (redirectUrlParts.protocol !== currentUrlParts.protocol && redirectUrlParts.protocol !== 'https:') ||
    (redirectUrlParts.host !== currentHost && !isSubdomain(redirectUrlParts.host, currentHost))
  ) {
    removeMatchingHeaders(/^(?:authorization|cookie)$/i, this._options.headers)
  }

  // Evaluate the beforeRedirect callback
  if (isFunction(beforeRedirect)) {
    var responseDetails = {
      headers: response.headers,
      statusCode: statusCode,
    }
    var requestDetails = {
      url: currentUrl,
      method: method,
      headers: requestHeaders,
    }
    try {
      beforeRedirect(this._options, responseDetails, requestDetails)
    } catch (err) {
      this.emit('error', err)
      return
    }
    this._sanitizeOptions(this._options)
  }

  // Perform the redirected request
  try {
    this._performRequest()
  } catch (cause) {
    this.emit('error', new RedirectionError({cause: cause}))
  }
}

// Wraps the key/value object of protocols with redirect functionality
function wrap(protocols) {
  // Default settings
  var exports = {
    maxRedirects: 21,
    maxBodyLength: 10 * 1024 * 1024,
  }

  // Wrap each protocol
  var nativeProtocols = {}
  Object.keys(protocols).forEach(function (scheme) {
    var protocol = scheme + ':'
    var nativeProtocol = (nativeProtocols[protocol] = protocols[scheme])
    var wrappedProtocol = (exports[scheme] = Object.create(nativeProtocol))

    // Executes a request, following redirects
    function request(input, options, callback) {
      // Parse parameters
      if (isString(input)) {
        var parsed
        try {
          parsed = urlToOptions(new URL(input))
        } catch (err) {
          /* istanbul ignore next */
          parsed = url.parse(input)
        }
        if (!isString(parsed.protocol)) {
          throw new InvalidUrlError({input})
        }
        input = parsed
      } else if (URL && input instanceof URL) {
        input = urlToOptions(input)
      } else {
        callback = options
        options = input
        input = {protocol: protocol}
      }
      if (isFunction(options)) {
        callback = options
        options = null
      }

      // Set defaults
      options = Object.assign(
        {
          maxRedirects: exports.maxRedirects,
          maxBodyLength: exports.maxBodyLength,
        },
        input,
        options,
      )
      options.nativeProtocols = nativeProtocols
      if (!isString(options.host) && !isString(options.hostname)) {
        options.hostname = '::1'
      }

      // assert.equal(options.protocol, protocol, 'protocol mismatch')
      return new RedirectableRequest(options, callback)
    }

    // Executes a GET request, following redirects
    function get(input, options, callback) {
      var wrappedRequest = wrappedProtocol.request(input, options, callback)
      wrappedRequest.end()
      return wrappedRequest
    }

    // Expose the properties on the wrapped protocol
    Object.defineProperties(wrappedProtocol, {
      request: {value: request, configurable: true, enumerable: true, writable: true},
      get: {value: get, configurable: true, enumerable: true, writable: true},
    })
  })
  return exports
}

/* istanbul ignore next */
function noop() {
  /* empty */
}

// from https://github.com/nodejs/node/blob/master/lib/internal/url.js
function urlToOptions(urlObject) {
  var options = {
    protocol: urlObject.protocol,
    hostname: urlObject.hostname.startsWith('[')
      ? /* istanbul ignore next */
        urlObject.hostname.slice(1, -1)
      : urlObject.hostname,
    hash: urlObject.hash,
    search: urlObject.search,
    pathname: urlObject.pathname,
    path: urlObject.pathname + urlObject.search,
    href: urlObject.href,
  }
  if (urlObject.port !== '') {
    options.port = Number(urlObject.port)
  }
  return options
}

function removeMatchingHeaders(regex, headers) {
  var lastValue
  for (var header in headers) {
    if (regex.test(header)) {
      lastValue = headers[header]
      delete headers[header]
    }
  }
  return lastValue === null || typeof lastValue === 'undefined' ? undefined : String(lastValue).trim()
}

function createErrorType(code, message, baseClass) {
  // Create constructor
  function CustomError(properties) {
    Error.captureStackTrace(this, this.constructor)
    Object.assign(this, properties || {})
    this.code = code
    this.message = this.cause ? message + ': ' + this.cause.message : message
  }

  // Attach constructor and set default properties
  CustomError.prototype = new (baseClass || Error)()
  CustomError.prototype.constructor = CustomError
  CustomError.prototype.name = 'Error [' + code + ']'
  return CustomError
}

function destroyRequest(request, error) {
  for (var event of events) {
    request.removeListener(event, eventHandlers[event])
  }
  request.on('error', noop)
  request.destroy(error)
}

function isSubdomain(subdomain, domain) {
  // assert(isString(subdomain) && isString(domain))
  var dot = subdomain.length - domain.length - 1
  return dot > 0 && subdomain[dot] === '.' && subdomain.endsWith(domain)
}

function isString(value) {
  return typeof value === 'string' || value instanceof String
}

function isFunction(value) {
  return typeof value === 'function'
}

function isBuffer(value) {
  return typeof value === 'object' && 'length' in value
}

// Exports
export default wrap({http: http, https: https})
export {wrap}
